跳到主要内容

PHP 充血模型架构学习

以现场开盘的后台为例:

基础服务层

middlend/common/BaseController.php

基础服务层没啥好说,就是把一般的通用操作,校验登录态,鉴权,记录访问日志之类的通用操作

执行前:beforeAction

0、记录请求日志 1、检查 CSRF 跨站请求伪造 2、校验登录态,如果是访客模式或者未取得用户,则跳转到登录页

middlend/models/User.php

3、加载基类中的 roleAuthRules 方法,并验证权限点

  • action 就是请求的方法名称。e.g. AddActivity
  • 如果未写到 actions 的 Controller 方法不校验

注:如果是在线开盘,这里还有一个切换到拆库的逻辑

执行后:afterAction

记录响应日志

UI 层

middlend/controllers/xckp/ActivityController.php
/**
* 编辑开盘活动
*/
public function actionEditActivity()
{
$this->exitIfNotPost();
$form = new ActivityEditForm();
$form->load(Yii::$app->request->post());
AjaxResult::json($form->editActivity());
}

把参数装载到 Form 中(它承接了 DTO 的操作),通过 rules 完成参数的绑定与参数校验

middlend/models/xckp/activity/ActivityEditForm.php

同时这个 Form 承接传统的 Controller 的职责,去调用对应的 Service 完成工作

中间胶水层

PHP 的这个架构设计的有点特殊,它通过一个 Repository 来完成类似于 SpringBoot 那样的自动注入操作

// common/repository/xckp/ActivityRepository.php

// 内部实际的构造方法可以看出它实际上包含了 Model 层、Service 层、一个 DTO
public static function createInstance()
{
if (self::$instance === null) {
self::$instance = new self(
ActivityService::className(),
Activity::className(),
ActivityDTO::className()
);
}
return self::$instance;
}

可以看到与上面的 From 每次都创建一个新的实例不同,这个 ActivityRepository 是一个单例。

注意:PHP 这个语言有点特殊,它每一个请求实际都是通过 php-fpm 服务去创建的进程,而这个进程请求结束后就被释放了,所以它就没办法像 Java 那样简单的把单例挂在当前服务的内存里面,下次调用时直接从内存中再次取得数据。所以这个单例是在一个请求内的,下次请求还会创建

首先看一下上面的 UI 层是怎样去调用这个胶水层的?

// 取得实际的服务层
private function getActivityService()
{
$activityService = ActivityRepository::getInstance()->getById($this->activityId);
if ($activityService === null) {
throw (new ActivityNotExistsException());
} else {
return $activityService;
}
}

那为啥不直接对 ActivityService 做单例,而是做个胶水层的单例呢?

首先我们来探究一下在 SpringBoot 中的 Service 是如何工作的?在 SpringBoot 中的单例并不保持状态,一切的数据都是通过方法传递进去执行的后再返回出来的,这里核心就在于这个 Service 不保存状态,它传输的数据也是贫血模型。

但是我们的架构并不是这样设计的。PHP 的 Service 通过桥接的方式内嵌了一个 DTO 去保存了状态:

class ActivityService extends RefactorBaseService implements ISmsSource {
/**
* @var ActivityDTO
*/
protected $dto;

// ....

所以实际上我们的这个 Service 是一个充血模型,即 Service 下面的方法实际上都是在操作这个 DTO,因此需要通过一个机制去绑定一个 DTO 到 Service 里面,同理另一个 Activity 实际上也是一个操作持久层的充血模型(Model),它也是保存了当前实体的状态。

基于上面的两个充血模型需要互相转换,因此可以将他们绑定在一起,就无需单独的做类型转换了。

服务层

common/services/xckp/activity/ActivityDTO.php
common/services/xckp/activity/ActivityService.php

这个服务层和 DTO 是通过桥接的方式绑定在一起的充血模型,所以它的操作实际上是作用于自己身上的,更有一种面向对象的风格,即我作用于自己,而不是靠另一个无状态的层去操作自己

持久层

common/models/xckp/Activity.php

持久层同上

知识补充说明:

DTO 就是典型的贫血模型,里面只有数据,没有一星半点的业务逻辑。贫血模型适合使用的场景就是承载和传递数据。

贫血模型的对立面就是充血模型了,也就是说,类型成员中除了承载数据的属性外,还有和其职责相关的方法。有的甚至在属性的 get 或 set 方法里面写上一些数据校验或数据转换的逻辑。

充血模型其实很简单,就是面向对象设计的本质:“一个对象是拥有状态和行为的”,比如说一个人,他眼睛什么样鼻子什么样这就是状态,人可以去打游戏或是写程序,这就是行为。

贫血模型最早广泛应用是源自于 EJB2,最强盛时期则是由 Spring 创造,把“行为”(也称为逻辑、过程)和“状态”(可理解为数据,对应到语言就是对象成员变量)分离到不同的对象之中,那个只有状态的对象就是所谓的“贫血对象”(常称为VO——Value Object),而那个只有行为的对象就是我们常见的 N 层结构中的 Logic/Service/Manager 层(对应到EJB2中的Stateless Session Bean)。(曾经Spring的作者Rod Johnson也承认,Spring不过是在沿袭EJB2时代的“事务脚本”,也就是面向过程编程)

贫血模型

此种模型下领域对象的作用很简单,只有所有属性的 get/set 方式,以及少量简单的属性值转换,不包含任何业务逻辑,不关系对象持久化,只是用来做为数据对象的承载和传递的介质。

@Entity
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class User {

@Id
private String userId;
private String userName;
private String password;
private boolean isLock;
}

而真正的业务逻辑则由领域服务负责实现,此服务引入持久化仓库,在业务逻辑完成之后持久化到仓库中,并在此可以发布领域事件(Domain Event)

public interface UserService {

void create(User user);
void edit(User user);
void changePassword(String userId, String newPassword);
void lock(String userId);
void unlock(String userId);

}

@Service
public class UserServiceImpl implements UserService {

@Autowired
private UserRepository repo;

@Override
public void edit(User user) {
User dbUser = repo.findById(user.getUserId()).get();
dbUser.setUserName(user.getUserName());
repo.save(dbUser);
// 发布领域事件 ...
}

@Override
public void lock(String userId) {
User dbUser = repo.findById(userId).get();
dbUser.setLock(true);
repo.save(dbUser);
// 发布领域事件 ...
}

// ... 省略完整代码
}

优点: 结构简单,职责单一,相互隔离性好,使用单例模型提高运行性能

缺点: 对象状态与行为分离,不能直观地描述领域对象。行为的设计主要考虑参数的输入和输出而非行为本身,不太具有面向对象设计的思考方式。行为间关联性较小,更像是面向过程式的方法,可复用性也较小。

SpringBoot 采用单例模式,尽量不手动创建对象,对象无状态化,故较推荐使用贫血模型

充血模型

此种模型下领域对象作用此领域相关行为,包含此领域相关的业务逻辑,同时也包含对领域对象的持久化操作。

@Entity
@Data
@Builder
@AllArgsConstructor
public class User implements UserService {

@Id
private String userId;
private String userName;
private String password;
private boolean isLock;

// 持久化仓库
@Transient
private UserRepository repo;

// 是否是持久化对象
@Transient
private boolean isRepository;

@PostLoad
public void per() {
isRepository = true;
}

public User() {
}

public User(UserRepository repo) {
this.repo = repo;
}

@Override
public void create(User user) {
repo.save(user);
}

@Override
public void edit(User user) {
if (!isRepository) {
throw new RuntimeException("用户不存在");
}

userName = user.userName;
repo.save(this);
// 发布领域事件 ...
}


@Override
public void lock() {
if (!isRepository) {
throw new RuntimeException("用户不存在");
}

isLock = true;
repo.save(this);
// 发布领域事件 ...
}

}

优点: 对象自洽程度很高,表达能力很强,因此非常适合于复杂的企业业务逻辑的实现,以及可复用程度比较高,更符合面向对象设计思想

缺点: 对象属性中掺杂持久化仓库,不够纯粹,持久化操作是否属于业务逻辑有待求证。但由于持久化仅需暴露接口,对业务逻辑与持久化操作的耦合度有一定降低。

说明: 有人认为对象中的 Create(),是新建对象方法不应该属于对象本身,应由其它对象产生或 static 方法产生。我的理解是不能把业务对象中的新建和程序对象上的新建混淆。业务对象的新建是指的是业务行为操作得出的结果,理应属于对象本身行为。而程序里的新建则是对象初始化过程 New(),这是程序构建逻辑不是业务概念,不能相等对待。

在领域对象行为逻辑较复杂的情况下,需要多个行为共享对象状态的时候,充血模型表现力更强,个人比较推荐此种模型